Skip to content

feat: add evaluate_flags() API for single-call flag evaluation#137

Open
dmarticus wants to merge 4 commits intomainfrom
posthog-code/ruby-evaluate-flags-api
Open

feat: add evaluate_flags() API for single-call flag evaluation#137
dmarticus wants to merge 4 commits intomainfrom
posthog-code/ruby-evaluate-flags-api

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Summary

  • Adds Client#evaluate_flags(distinct_id, …) returning a FeatureFlagEvaluations snapshot — one /flags round-trip powers both branching and event enrichment.
  • Snapshot exposes is_enabled / get_flag / get_flag_payload for branching, plus only_accessed / only([keys]) to narrow what gets attached to a captured event.
  • Adds flags: option on capture and capture_exception — when present, attaches $feature/<key> and $active_feature_flags from the snapshot without any extra /flags call.
  • flag_keys: on evaluate_flags scopes the underlying /flags request itself (sent as flag_keys_to_evaluate).
  • Deprecates is_feature_enabled, get_feature_flag, get_feature_flag_result, get_feature_flag_payload, and capture(send_feature_flags:) — they keep working but now emit a DeprecationWarning pointing at evaluate_flags(). Removal is planned for the next major.
  • Adds a feature_flags_log_warnings: client option (default true) to silence the snapshot's filter-helper warnings.

References

RFC: https://github.com/PostHog/requests-for-comments-internal/pull/1020 · mirrors posthog-python#539 and posthog-js#3476.

Design decisions

  • is_enabled returns false for unknown flags, get_flag returns nil — matches the legacy single-flag methods so existing branching code is structurally interchangeable.
  • get_flag_payload deliberately does not record access or fire $feature_flag_called — payload-only reads shouldn't count as an exposure.
  • only_accessed() returns an empty snapshot when nothing has been accessed (it honors its name) — pre-access flags first if you want a populated result.
  • Filtered snapshot clones get a copy of the parent's accessed set, so calls on a clone don't back-propagate exposures into the parent.
  • A small Host struct (two lambdas: capture_flag_called_event_if_needed, log_warning) is passed to the snapshot instead of a back-reference to the full Client — keeps the snapshot decoupled and testable.
  • Locally-evaluated flags are tagged with locally_evaluated: true, reason "Evaluated locally", and $feature_flag_definitions_loaded_at on emitted events, matching the existing single-flag local path.
  • Response-level errors (errorsWhileComputingFlags, quotaLimited) and per-flag errors (flag_missing) are combined into a comma-joined $feature_flag_error so each event keeps the granular error code(s).
  • Passing both flags: and send_feature_flags: to capture uses the snapshot and warns — the snapshot guarantees the event carries the same values your code branched on.
  • Deprecated methods bypass each other internally (is_feature_enabled and get_feature_flag call a private _get_feature_flag_result directly) so a single user-level call emits exactly one warning, not a cascade.

Phase 2 follow-ups

  • Update docs at https://posthog.com/docs/libraries/ruby and the feature-flags pages so the wizard / new-app instrumentation points at evaluate_flags().
  • Consider stashing the active flags snapshot on a request-scoped context so auto-captured exceptions inherit the same flag context as manual captures.

All tests + lint pass on Ruby 3.2 / 3.3 / 3.4 (380 examples).


Created with PostHog Code

Add Client#evaluate_flags(distinct_id, ...) returning a
FeatureFlagEvaluations snapshot, and a flags: option on capture so
a single /flags call can power both flag branching and event
enrichment per request.

The snapshot exposes is_enabled, get_flag, get_flag_payload, plus
only_accessed / only([keys]) filter helpers. flag_keys: scopes the
underlying /flags request itself. is_enabled and get_flag fire
$feature_flag_called events with full metadata (id, version, reason,
request_id), deduped through the existing per-distinct_id cache.
get_flag_payload does not record access or fire an event.

The dedup + capture in get_feature_flag_result is extracted into
_capture_feature_flag_called_if_needed and shared between the existing
path and the snapshot's access-recording.

Existing is_feature_enabled, get_feature_flag, get_feature_flag_result,
get_feature_flag_payload, and capture(send_feature_flags:) continue
to work unchanged.

Generated-By: PostHog Code
Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:11
@dmarticus dmarticus requested a review from a team as a code owner April 27, 2026 22:11
Mirrors the changes that landed on the Python PR after review:

- only_accessed returns an empty snapshot when nothing has been
  accessed (drops the warn-and-fall-back-to-all-flags behavior — that
  was a misguided safety net that surprised callers in the
  early-pre-access pattern).
- capture(flags:, send_feature_flags:) now warns when both are passed,
  uses the snapshot, and ignores send_feature_flags. The snapshot
  guarantees the event carries the values branched on; the precedence
  was previously implicit.
- $feature_flag_called events now carry response-level errors
  (errors_while_computing_flags, quota_limited) combined with per-flag
  errors (flag_missing) as a comma-joined $feature_flag_error, matching
  the granularity of the legacy single-flag path.
- capture_exception now accepts a flags: kwarg and forwards it to the
  inner capture() so $exception events can carry the same flag context
  as other events.
- Phase 2 deprecation warnings ship alongside Phase 1: is_feature_enabled,
  get_feature_flag, get_feature_flag_result, get_feature_flag_payload,
  and capture(send_feature_flags:) emit Kernel.warn(..., category:
  :deprecated) pointing at evaluate_flags(). Public methods bypass
  each other internally (via a new private _get_feature_flag_result)
  so a single user-level call emits exactly one warning.

Generated-By: PostHog Code
Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
Comment thread lib/posthog/feature_flag_evaluations.rb Outdated
@flags.keys
end

def is_enabled(key) # rubocop:disable Naming/PredicateName
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enabled?(key) feels more Ruby idiomatic here

Comment thread lib/posthog/feature_flag_evaluations.rb Outdated
key = key.to_s
flag = @flags[key]
response = flag&.enabled ? true : false
_record_access(key, flag, response)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For variant flags, this will emit the $feature_flag_called event with $feature/variant-flag: true | false instead of the variant. This means is_enabled and get_flag will both emit $feature_flag_called due to the different response.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly, response will be false instead of nil if the flag is missing

Comment thread lib/posthog/client.rb Outdated
end

@before_send = opts[:before_send]
@feature_flags_log_warnings = opts.key?(:feature_flags_log_warnings) ? opts[:feature_flags_log_warnings] : true
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this, or should we gate via log level?

config.log_level = :debug

similar to PostHog/posthog-android#498 (comment)

Comment thread lib/posthog/client.rb Outdated
Comment on lines +210 to +216
Kernel.warn(
'`send_feature_flags` is deprecated and will be removed in a future major version. ' \
'Pass a `flags` snapshot from `client.evaluate_flags(...)` instead — it avoids a ' \
'second `/flags` request per capture and guarantees the event carries the exact ' \
'flag values your code branched on.',
category: :deprecated, uplevel: 1
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, we don't have a great way of presenting deprecations in this SDK.

Deprecation warnings are no longer shown by default (since Ruby 2.7.2). Turn them on with -W:deprecated (or with -w to show other warnings too)

Realistically I don't know if anyone is going to see this. Maybe we drop the :deprecated category and only log once per unique method? ActiveSupport has it's own deprecation API, so I don't think we'd be stepping outside of what's acceptable.

Comment thread lib/posthog/client.rb Outdated
errors_while_computing = false
quota_limited = false

unless only_evaluate_locally
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might have matched all the flags locally already, but unless only_evaluate_locally is true we'll still hit /flags

Comment thread lib/posthog/client.rb Outdated
key: key_str,
enabled: ff.enabled ? true : false,
variant: ff.variant,
payload: ff.payload,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should normalize the payload to a deserialized type

Comment thread lib/posthog/client.rb
Comment on lines +552 to +554
flags_response = @feature_flags_poller.get_flags(
distinct_id, groups, person_properties, group_properties, flag_keys
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We accept disable_geoip as a param but don't do anything with it. get_flags doesn't look to support it.

Comment thread lib/posthog/client.rb Outdated
poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {}

poller_flags_by_key.each do |key, definition|
next if flag_keys && !flag_keys.map(&:to_s).include?(key.to_s)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer using a set here

Comment thread lib/posthog/client.rb Outdated
'capture(); using `flags` and ignoring `send_feature_flags`.'
)
end
snapshot_props = attrs[:flags]._get_event_properties
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should guard against the NoMethodError here

Comment thread lib/posthog/client.rb Outdated
# Internal: implementation of {#get_feature_flag_result}, called by both the
# public method and the legacy `is_feature_enabled` / `get_feature_flag`
# paths. Bypassing the public wrapper avoids cascading deprecation warnings.
def _get_feature_flag_result(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in the public section, can it be moved to private?

Snapshot:
- Rename is_enabled → enabled? for Ruby idiom (the legacy
  client-level is_feature_enabled keeps its name but is now deprecated).
- Canonicalize the recorded $feature_flag_response across
  enabled?/get_flag so a variant flag accessed both ways collapses to
  one $feature_flag_called event (with the variant value) instead of
  two with mismatched booleans. Same fix for missing flags: response
  is nil regardless of which method was called.

Client / poller:
- Drop the feature_flags_log_warnings config option in favor of
  standard log-level gating: warnings flow through logger.warn so
  callers silence them via their existing log config (mirrors the
  posthog-android feedback).
- Skip the remote /flags call when flag_keys are all locally
  resolved. Without flag_keys we still hit the server because we don't
  know about server-side-only flags.
- Forward disable_geoip to the /flags request body as geoip_disable.
- Use a Set for flag_keys lookup to keep the per-flag check O(1).
- Normalize remote and local-eval payloads via
  FeatureFlagResult.parse_payload (made public — same contract as
  FeatureFlagResult.from_value_and_payload uses internally).
- Guard capture(flags:) against non-snapshot values: log a warning
  and ignore instead of raising NoMethodError.
- Move _get_feature_flag_result into the private section.

Deprecation warnings:
- Drop the :deprecated category — Ruby suppresses those by default
  since 2.7.2 and most users wouldn't see them. Use Kernel.warn with
  a [posthog-ruby] DEPRECATION prefix; standard log-level / IO
  silencing still works.
- Emit each deprecation at most once per (method, client) pair via
  Concurrent::Set so high-volume callers don't pay a per-call cost.

Generated-By: PostHog Code
Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
The changeset was written before the is_enabled → enabled? rename
that came out of Dustin's review; align the example snippet and
description so the generated CHANGELOG entry uses the final API
naming. Also softens "DeprecationWarning" to "one-time deprecation
warning per method" since we dropped the :deprecated category in
favor of always-visible Kernel.warn.

Generated-By: PostHog Code
Task-Id: fe67a5bd-7d33-4568-9148-39d181660a5a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants